배열기반 다대다 관계에 중간테이블 도입 {mongodb}

문제정의

Prisma가 만들어준 M:N 관계 스키마를 사용하지 않고 Mongodb Compass에서 직접 Delete 작업을 수행하려니 고충이 너무 많이 든다. 그리고 Category SoftDelete를 수행하니 Academy.categoryIds 쪽에 아무런 일도 일어나지 않아 정합성이 깨져버린다. 이 문제를 어떻게 해결하면 좋을까?

ASIS

class AcademyCategoryRepository{
  async deleteCategory(id: ObjectId) {
    return await this.prisma.academyCategory.update({
      where: { id },
      data: {
        deletedTime: new Date(),
      },
    });
  }
}
model Academy {
  id                 String               @id @default(auto()) @map("_id") @db.ObjectId
  name               String
  categoryIds       String[]                @db.ObjectId @default([])
  categories        AcademyCategory[]       @relation(fields: [categoryIds], references: [id])

  @@index([name], map: "name_1")
  @@map("academymodels")
}

model AcademyCategory {
  id                String  @id @default(auto()) @map("_id") @db.ObjectId
  name              String  @unique
  description       String?
  createdTime       DateTime @default(now()) @db.Date
  updatedTime       DateTime @updatedAt @db.Date
  deletedTime       DateTime? @db.Date

  academyIds        String[]  @db.ObjectId
  academies         Academy[] @relation(fields: [academyIds], references: [id])
  
  @@map("academycategorymodels")
}

AI Response: ChatGPT 4o

이 문제는 Prisma와 MongoDB의 M:N 관계 모델링 방식의 한계와 soft delete 방식이 맞물려 발생한 정합성 문제입니다. 구체적으로는 AcademyCategory soft delete 시 해당 카테고리를 참조하는 Academy.categoryIds에서의 정합성 유지를 Prisma가 자동으로 해주지 않기 때문에 발생합니다. 이를 해결하기 위한 두 가지 큰 방향이 있습니다:


✅ 권장 접근: 명시적 관계 갱신 처리

Soft delete를 수행할 때 직접 관련된 AcademycategoryIds 필드에서도 해당 Category.id를 제거해야 정합성을 유지할 수 있습니다. 이를 위해 다음과 같은 명시적 처리 로직이 필요합니다.

🔧 개선된 Soft Delete 로직

class AcademyCategoryRepository {
  async deleteCategory(id: ObjectId) {
    // Step 1: Soft-delete the category
    await this.prisma.academyCategory.update({
      where: { id },
      data: {
        deletedTime: new Date(),
      },
    });

    // Step 2: Remove category reference from all academies
    await this.prisma.academy.updateMany({
      where: {
        categoryIds: {
          has: id.toString(),  // ObjectId는 string으로 저장됨
        },
      },
      data: {
        categoryIds: {
          set: Prisma.sql`categoryIds.filter(cid => cid !== ${id.toString()})` as any, // or handle in app logic
        },
      },
    });
  }
}

하지만 Prisma에서는 MongoDB의 array 필드에서 특정 값을 삭제하는 native 지원이 부족하므로, updateMany는 잘 안 먹힐 수 있습니다. 따라서 모든 academy를 조회 후 app 로직에서 배열 조작이 필요합니다:

🔁 안전한 Soft Delete with Manual Array Update

const academies = await this.prisma.academy.findMany({
  where: {
    categoryIds: {
      has: id.toString(),
    },
  },
});

await Promise.all(
  academies.map(academy =>
    this.prisma.academy.update({
      where: { id: academy.id },
      data: {
        categoryIds: academy.categoryIds.filter(
          cid => cid !== id.toString(),
        ),
      },
    }),
  ),
);

🧼 차선책: Soft Delete 필터링 일관화 (정합성보단 UX 일관성)

AcademyCategory.deletedTime !== null인 경우 모든 API 응답과 UI에서 제외시켜 정합성 보다는 UX 레벨 일관성을 지키는 방식입니다. 이 경우 Academy.categoryIds에 존재하지만 실제 카테고리는 "삭제된 것처럼" 보이게 하므로, 데이터 정합성은 손해를 감수하는 대신 코드 간소화를 얻을 수 있습니다.

예시: Academy 조회시 join 필터링

const academy = await this.prisma.academy.findUnique({
  where: { id },
  include: {
    categories: {
      where: {
        deletedTime: null,
      },
    },
  },
});

단, 이 방식은 "카테고리가 삭제되었는데 Academy.categoryIds에 여전히 존재하는 이상한 상태"를 DB 레벨에서 허용하기 때문에 추천하지 않습니다. 통합 정합성 유지가 중요하다면 위의 명시적 제거 로직을 사용하는 것이 좋습니다.


🔐 보너스 팁: M:N 모델링 개선 고려

현재 구조는 MongoDB에서는 추천되지 않는 방식입니다. MongoDB에서 Many-to-Many는 일반적으로 Join Table을 직접 설계하는 형태가 더 유지보수에 좋습니다.

예를 들어:

model AcademyCategoryAcademy {
  id           String   @id @default(auto()) @map("_id") @db.ObjectId
  academyId    String   @db.ObjectId
  categoryId   String   @db.ObjectId
  deletedTime  DateTime? @db.Date
}

이런 중간 모델을 두면 정합성 유지 및 soft delete 시 유연성이 훨씬 높아집니다.


✨ 정리

방식 설명 장점 단점
✅ 명시적 관계 해제 처리 Soft delete 시 Academy의 categoryIds에서 제거 정합성 유지 로직 복잡
🧼 Soft delete만 필터링 deletedTime 기반으로 UI/응답 필터 구현 간단 데이터 불일치 가능
🔁 M:N 중간 테이블 도입 중간 모델 생성 유연한 확장, 쿼리 명확 스키마 변경 필요

필요하다면 M:N 중간 테이블 방식으로 완전한 구조 개선까지 도와드릴게요.
혹시 지금 사용하는 구조에서 soft delete나 정합성 외에 고민되는 지점이 있나요?